Share one node_modules cache key so CI can actually restore it - #2257
Conversation
GitHub Actions scopes every cache entry to the ref that wrote it. A `pull_request` run can only read its own `refs/pull/N/merge` scope plus the default branch's, so the default branch is the only scope a cache can be shared through. `test.yml` triggers only on `pull_request` and keyed its cache `node-modules-*`; `deploy-base.yml` is the sole workflow running on push-to-main, and keyed the same directory, built from the same lockfile hash, as `deploy-node-modules-*`. One prefix apart, so nothing ever restored: every PR missed, ran `npm ci`, and saved another ~145 MB copy into a scope only it could read.
Measured before this change: 194 entries / 10.59 GB against GitHub's 10 GB per-repo cap, so the store was permanently evicting. 49 of the 50 `node-modules-*` entries were PR-scoped (6.93 GB), and one lockfile hash had ten byte-identical copies, one per PR. `npm ci` ran on 13 of 13 sampled branches at 25-34s each. The current lockfile hashes to ef5593458e..., and `deploy-node-modules-...ef5593458e...` was sitting in the main scope the whole time — the exact bytes every PR needed, under the wrong name.
Unifying the prefix fixes the write side too. `actions/cache` only saves on a miss, so once PRs restore from main they stop writing per-PR copies, and the family collapses to roughly one entry per lockfile revision.
Also turn off setup-node's automatic `~/.npm` cache wherever a job already caches `node_modules`. It is on by default because package.json declares `packageManager` (npm@11.11.0), so it was running in jobs nobody configured for it, including `auth-tables-doc-drift`, which installs nothing at all. It cannot pay for itself alongside a `node_modules` tarball: both key on hashFiles('package-lock.json'), so they hit and miss together — when node_modules restores, npm never runs; when the lockfile changes, ~/.npm misses too. That is ~169 MB per lockfile revision per job. `migrate-dryrun` and `schema-shape-report.yml` install a subset of deps without a node_modules tarball, genuinely benefit, and keep their explicit `cache: 'npm'` opt-in.
`tests/unit/scripts/ci-node-modules-cache.test.ts` pins both invariants. Reverting either half turns it red: the key prefix fails 2 assertions, dropping a single `package-manager-cache: false` fails 1 and names the job. The guard reads YAML with comment lines stripped — its first cut tripped on its own explanatory prose, which quotes both `cache: 'npm'` and the retired prefix verbatim.
deploy-base.yml already routes buildx layers to ECR specifically to avoid thrashing this same 10 GB cap. The node_modules caches had quietly recreated the thrash it was avoiding.
Closes #2256
|
CI green — run 32681188331, all jobs passing ( The cache log confirms the mechanism, including one thing I didn't plan for:
The npm-cache half is confirmed by absence: zero Job durations, for the record: AC#1 is not verifiable on this PR by construction — it needs a merge first. I'll confirm it on the next PR that lands afterward. |
…ards Review found the guard could be skipped on exactly the PRs it exists to catch. `unit-tests` is gated on `detect-changes.outputs.src`, whose paths filter listed `.github/workflows/test.yml` but not `deploy-base.yml`. A PR reverting deploy-base's cache key — the literal defect — touches no `src` path, so `unit-tests` never runs, the required check reports `skipped`, and branch protection accepts that as passing. This is the second time that trap has sprung: BS#1807 added `test.yml` to `src` for the same reason, one file at a time. Widened to `.github/workflows/**`, since the guard globs the whole directory. Drop the `package-manager-cache: false` half entirely. Its stated rationale — npm never runs when node_modules restores, so a ~/.npm cache can never pay for itself — is false in two of the six jobs it touched. Integration-Tests runs `npm ci` in dev_env/mock-api-server (express + typescript + @types), a separate project whose deps root node_modules never contains, and deploy-base runs `npm ci` unconditionally. Worse, deploy-base and nightly-tests are the only runs that write `node-cache-*` on the default branch, so disabling it there would have starved migrate-dryrun and schema-shape-report.yml — both pull_request-only, both keeping an explicit `cache: 'npm'` — of the only scope they can restore from. That is the same unrestorable-scope bug this PR is fixing, inverted. It is a separable optimization that needs measurement, not a rider on this change. Correct the sole-writer claim. `nightly-tests.yml` is `schedule`-triggered, and scheduled runs execute on the default branch, so it also writes the shared key there. It was never true that nothing could populate the shared scope. The real asymmetry is cadence: nightly fires once a day and its entry goes stale the moment the lockfile moves, which is precisely the state main was found in — one `node-modules-*` entry, at a stale hash. deploy-base runs on every merge, which is what keeps a current entry in the shared scope. Close a silent-decay hole in the guard. `nodeModulesCacheSteps` required `path` to be an inline scalar, so rewriting one step as a block list — a natural edit when adding a second directory — would have dropped it out of the checked set while every assertion kept passing. It now parses both forms and pins the step count exactly rather than as a floor. Verified by mutation: reverting the key prefix fails 2 assertions, narrowing the src filter fails 1, and rewriting a path as a block list keeps the step in the checked set rather than silently exempting it.
|
Review addressed in HIGH — HIGH — disabling Two things killed it:
One correction to the finding, which doesn't change the conclusion: the two keys are the same hash. setup-node keys on root-only LOW — the sole-writer claim is wrong. Correct. LOW — the guard can decay silently. Correct. LOW — deploy-base restores a tarball Net diff is now just the key prefix, the trigger fix, the guard, and the doc rule. Re-verified locally: typecheck clean, 473 suites / 8188 tests passing, eslint 0 errors, prettier clean, |
CI verification of the previous commit turned up a second defect that made the first one moot: every job that restored the cache printed "Cache corrupted, will reinstall", deleted node_modules, and ran `npm ci` anyway. The cache had never saved a second even when it hit. This is an npm workspaces repo (`apps/*`, `jobs/*`, `shared/*`) and dependencies do not all hoist — `apps/backend`, `apps/auth` and `shared/authentication` each carry their own node_modules. Every cache step cached `path: node_modules`, the root only. On restore the workspaces' own deps are missing, so `npm ls --depth=0` reports root's hoisted copies as `invalid` against the workspace ranges and exits 1, which is exactly what the `Validate cached node_modules` gate treats as corruption. Reproduced locally: moving `apps/backend/node_modules` aside flips `npm ls --depth=0` from 0 to 1 with `axios@1.18.1 invalid: "^1.19.0" from apps/backend`. So AC#1 of #2256 — a PR that doesn't touch package-lock.json skips Install Dependencies — was unreachable by the key fix alone. A partial tree under a shared key is worse than no cache: it pays the download and then reinstalls. The path list now mirrors package.json's `workspaces`. The key gains a `-v2` because the path set changed while the lockfile hash did not, and actions/cache never overwrites an existing key — without the bump every job would keep restoring the old, incomplete entry. The guard pins the path list as well as the key, since a shared key over differing path sets is its own correctness bug. Verified by mutation: reverting one step to `path: node_modules` fails the new assertion. Docs rule compressed to a pointer — check-doc-rules flagged it as a compress-candidate once the test carried the load, which is the convention that script exists to enforce.
|
AC#1 is now demonstrated on this PR — and getting there turned up a second defect that made the first fix moot on its own. Verifying the previous commit, I noticed every job that restored the cache printed Cause: this is an npm workspaces repo ( The path list now mirrors The two runs side by side, same PR, same lockfile:
That is AC#1's exact mechanism, just proven within one PR's scope instead of across two. Cross-PR it needs the merge first, so The guard now pins the path list alongside the key — a shared key over differing path sets is its own correctness bug, since a partial tree under a key that promises a full one is worse than no cache at all. Reverting one step to Re-verified: typecheck clean, 473 suites / 8189 tests, eslint 0 errors, prettier clean, and |
Closes #2256.
The bug
GitHub Actions scopes every cache entry to the ref that wrote it. A
pull_requestrun can read its ownrefs/pull/N/mergescope and the default branch's — nothing else. So the default branch is the only scope a cache can be shared through.test.ymltriggers only onpull_requestand keyed its cachenode-modules-*.deploy-base.ymlis the one workflow that runs on push-to-main, and it keyed the same directory, built from the same lockfile hash, asdeploy-node-modules-*.One prefix apart. So nothing ever restored: every PR missed, ran
npm ci, and saved another ~145 MB copy into a scope only that PR could read.Measured before the change
node-modules-*npm ciThe current lockfile hashes to
ef5593458e…, anddeploy-node-modules-…ef5593458e…was sitting in the main scope the whole time — the exact bytes every PR needed, under the wrong name.Why unifying the prefix is the whole fix
actions/cacheonly saves on a miss. Once PRs restore from main's entry they stop writing per-PR copies, so the family collapses to roughly one entry per lockfile revision instead of one per PR. That addresses both acceptance criteria in #2256 — the restore and the size — with one change rather than a pruning cron.It is self-healing after one merge: this PR's own merge runs
deploy-auto→deploy-base, which writesnode-modules-<current hash>into the main scope for every subsequent PR to read.Second change: stop paying for a second cache that can never help
setup-node'spackage-manager-cachedefaults to true wheneverpackage.jsondeclarespackageManager— ours saysnpm@11.11.0. So everysetup-nodein this repo was caching ~169 MB of~/.npmthat nobody configured, includingauth-tables-doc-drift, which installs nothing at all.Alongside a
node_modulestarball it can never pay for itself, because both key onhashFiles('package-lock.json'):npmnever runs →~/.npmgoes unread~/.npmmisses too → it cannot speed up the reinstallIt is disabled in the six jobs that cache
node_modules(or install nothing).migrate-dryrunandschema-shape-report.ymlinstall a subset of deps without a node_modules tarball, genuinely benefit, and keep their explicitcache: 'npm'opt-in.The guard
tests/unit/scripts/ci-node-modules-cache.test.ts— 14 tests pinning both invariants, plus a parser-sanity test so it can't pass vacuously. Verified it bites in both directions:deploy-node-modules-*package-manager-cache: falseIt strips YAML comment lines before matching. Its first cut tripped on its own explanatory prose, which quotes both
cache: 'npm'and the retired prefix verbatim — a guard that reads documentation instead of configuration is exactly the vacuous-pass failure mode #2249 was about.This is also the kind of spec that could not have been trusted before #2255: it reads workflow files as text, so Jest's affected-tests mode would never have selected it when a workflow changed.
Note
deploy-base.ymlalready routes buildx layers to ECR specifically to avoid thrashing this same 10 GB cap ("Don't switch totype=gha: … ~38 images' layer caches would thrash/evict each other"). The node_modules caches had quietly recreated the thrash it was avoiding.Local verification
npm run typecheck— cleannpm run test:unit— 473 suites / 8196 tests, all passingeslint— 0 errors (5 warnings on the new spec, allsecurity/detect-non-literal-*, same class its siblings emit)npm run check:docs— 7 findings, all pre-existing indocs/migrations.mdnpm run check:auth-tables-doc— PASSprettier --check— clean